/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.apache.rocketmq.schema.avro;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.IntNode;
import com.fasterxml.jackson.databind.node.JsonNodeFactory;
import com.fasterxml.jackson.databind.node.ObjectNode;
import io.openmessaging.connector.api.data.Field;
import io.openmessaging.connector.api.data.FieldType;
import io.openmessaging.connector.api.data.Schema;
import io.openmessaging.connector.api.data.SchemaAndValue;
import io.openmessaging.connector.api.data.SchemaBuilder;
import io.openmessaging.connector.api.data.Struct;
import io.openmessaging.connector.api.data.logical.Date;
import io.openmessaging.connector.api.data.logical.Decimal;
import io.openmessaging.connector.api.data.logical.Time;
import io.openmessaging.connector.api.data.logical.Timestamp;
import io.openmessaging.connector.api.errors.ConnectException;
import org.apache.avro.JsonProperties;
import org.apache.avro.generic.GenericData;
import org.apache.avro.generic.GenericEnumSymbol;
import org.apache.avro.generic.GenericFixed;
import org.apache.avro.generic.GenericRecord;
import org.apache.avro.generic.GenericRecordBuilder;
import org.apache.avro.generic.IndexedRecord;
import org.apache.avro.util.internal.JacksonUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.IOException;
import java.math.BigDecimal;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.IdentityHashMap;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.ListIterator;
import java.util.Map;
import java.util.Objects;
import java.util.Set;

/**
 * Avro serialization and deserialization
 */
public class AvroData {
    public static final String NAMESPACE = "org.apache.rocketmq.connect.avro";
    public static final String DEFAULT_SCHEMA_NAME = "ConnectDefault";
    public static final String DEFAULT_SCHEMA_FULL_NAME = NAMESPACE + "." + DEFAULT_SCHEMA_NAME;
    public static final String MAP_ENTRY_TYPE_NAME = "MapEntry";
    public static final String KEY_FIELD = "key";
    public static final String VALUE_FIELD = "value";
    public static final String CONNECT_NAME = "connect.name";
    public static final String CONNECT_DOC = "connect.doc";
    public static final String CONNECT_VERSION = "connect.version";
    public static final String CONNECT_DEFAULT_VALUE = "connect.default";
    public static final String CONNECT_PARAMETERS = "connect.parameters";
    public static final String AVRO_RECORD_DOC = NAMESPACE + ".record.doc";
    public static final String AVRO_ENUM_DOC_PREFIX = NAMESPACE + ".enum.doc.";
    public static final String AVRO_FIELD_DOC_PREFIX = NAMESPACE + ".field.doc.";
    public static final String CONNECT_INTERNAL_TYPE_NAME = "connect.internal.type";
    public static final String AVRO_FIELD_DEFAULT_FLAG = NAMESPACE + ".field.default";
    public static final String AVRO_ENUM_DEFAULT_PREFIX = NAMESPACE + ".enum.default.";
    public static final String CONNECT_TYPE = "connect.type";
    public static final String CONNECT_TYPE_INT8 = "int8";
    public static final String CONNECT_TYPE_INT16 = "int16";
    public static final String AVRO_TYPE_UNION = NAMESPACE + ".Union";
    public static final String AVRO_TYPE_ENUM = NAMESPACE + ".Enum";
    public static final String AVRO_TYPE_ANYTHING = NAMESPACE + ".Anything";
    public static final org.apache.avro.Schema ANYTHING_SCHEMA_MAP_ELEMENT;
    public static final org.apache.avro.Schema ANYTHING_SCHEMA;
    static final String AVRO_PROP = "avro";
    static final String AVRO_LOGICAL_TYPE_PROP = "logicalType";
    static final String AVRO_LOGICAL_TIMESTAMP_MILLIS = "timestamp-millis";
    static final String AVRO_LOGICAL_TIME_MILLIS = "time-millis";
    static final String AVRO_LOGICAL_DATE = "date";
    static final String AVRO_LOGICAL_DECIMAL = "decimal";
    static final String AVRO_LOGICAL_DECIMAL_SCALE_PROP = "scale";
    static final String AVRO_LOGICAL_DECIMAL_PRECISION_PROP = "precision";
    static final String CONNECT_AVRO_FIXED_SIZE = "connect.fixed.size";
    static final String CONNECT_AVRO_DECIMAL_PRECISION_PROP = "connect.decimal.precision";
    static final Integer CONNECT_AVRO_DECIMAL_PRECISION_DEFAULT = 64;
    private static final Logger log = LoggerFactory.getLogger(AvroData.class);
    private static final Map<String, FieldType> NON_AVRO_TYPES_BY_TYPE_CODE = new HashMap<>();
    // Avro Java object types used by Connect schema types
    private static final Map<FieldType, List<Class>> SIMPLE_AVRO_SCHEMA_TYPES = new HashMap<>();
    // Mapping connect type to avro type
    private static final Map<FieldType, org.apache.avro.Schema.Type> CONNECT_TYPES_TO_AVRO_TYPES = new HashMap<>();
    private static final String ANYTHING_SCHEMA_BOOLEAN_FIELD = "boolean";
    private static final String ANYTHING_SCHEMA_BYTES_FIELD = "bytes";
    private static final String ANYTHING_SCHEMA_DOUBLE_FIELD = "double";
    private static final String ANYTHING_SCHEMA_FLOAT_FIELD = "float";
    private static final String ANYTHING_SCHEMA_INT_FIELD = "int";
    private static final String ANYTHING_SCHEMA_LONG_FIELD = "long";
    private static final String ANYTHING_SCHEMA_STRING_FIELD = "string";
    private static final String ANYTHING_SCHEMA_ARRAY_FIELD = "array";
    private static final String ANYTHING_SCHEMA_MAP_FIELD = "map";
    private static final org.apache.avro.Schema NULL_SCHEMA =
            org.apache.avro.Schema.create(org.apache.avro.Schema.Type.NULL);
    // Convert values in Connect form into their logical types. These logical converters are
    // discovered by logical type names specified in the field
    private static final HashMap<String, LogicalTypeConverter> TO_CONNECT_LOGICAL_CONVERTERS
            = new HashMap<>();
    private static final HashMap<String, LogicalTypeConverter> TO_AVRO_LOGICAL_CONVERTERS
            = new HashMap<>();

    static {
        NON_AVRO_TYPES_BY_TYPE_CODE.put(CONNECT_TYPE_INT8, FieldType.INT8);
        NON_AVRO_TYPES_BY_TYPE_CODE.put(CONNECT_TYPE_INT16, FieldType.INT16);
    }

    static {
        SIMPLE_AVRO_SCHEMA_TYPES.put(FieldType.INT32, Collections.singletonList(Integer.class));
        SIMPLE_AVRO_SCHEMA_TYPES.put(FieldType.INT64, Collections.singletonList(Long.class));
        SIMPLE_AVRO_SCHEMA_TYPES.put(FieldType.FLOAT32, Collections.singletonList(Float.class));
        SIMPLE_AVRO_SCHEMA_TYPES.put(FieldType.FLOAT64, Collections.singletonList(Double.class));
        SIMPLE_AVRO_SCHEMA_TYPES.put(FieldType.BOOLEAN, Collections.singletonList(Boolean.class));
        SIMPLE_AVRO_SCHEMA_TYPES.put(FieldType.STRING, Collections.singletonList(CharSequence.class));
        SIMPLE_AVRO_SCHEMA_TYPES.put(FieldType.BYTES, Arrays.asList(ByteBuffer.class, byte[].class, GenericFixed.class));
        SIMPLE_AVRO_SCHEMA_TYPES.put(FieldType.ARRAY, Collections.singletonList(Collection.class));
        SIMPLE_AVRO_SCHEMA_TYPES.put(FieldType.MAP, Collections.singletonList(Map.class));
    }

    static {
        CONNECT_TYPES_TO_AVRO_TYPES.put(FieldType.INT32, org.apache.avro.Schema.Type.INT);
        CONNECT_TYPES_TO_AVRO_TYPES.put(FieldType.INT64, org.apache.avro.Schema.Type.LONG);
        CONNECT_TYPES_TO_AVRO_TYPES.put(FieldType.FLOAT32, org.apache.avro.Schema.Type.FLOAT);
        CONNECT_TYPES_TO_AVRO_TYPES.put(FieldType.FLOAT64, org.apache.avro.Schema.Type.DOUBLE);
        CONNECT_TYPES_TO_AVRO_TYPES.put(FieldType.BOOLEAN, org.apache.avro.Schema.Type.BOOLEAN);
        CONNECT_TYPES_TO_AVRO_TYPES.put(FieldType.STRING, org.apache.avro.Schema.Type.STRING);
        CONNECT_TYPES_TO_AVRO_TYPES.put(FieldType.BYTES, org.apache.avro.Schema.Type.BYTES);
        CONNECT_TYPES_TO_AVRO_TYPES.put(FieldType.ARRAY, org.apache.avro.Schema.Type.ARRAY);
        CONNECT_TYPES_TO_AVRO_TYPES.put(FieldType.MAP, org.apache.avro.Schema.Type.MAP);
    }

    static {
        ANYTHING_SCHEMA = org.apache.avro.SchemaBuilder
                .record(AVRO_TYPE_ANYTHING)
                .namespace(NAMESPACE)
                .fields()
                .optionalBoolean(ANYTHING_SCHEMA_BOOLEAN_FIELD)
                .optionalBytes(ANYTHING_SCHEMA_BYTES_FIELD)
                .optionalDouble(ANYTHING_SCHEMA_DOUBLE_FIELD)
                .optionalFloat(ANYTHING_SCHEMA_FLOAT_FIELD)
                .optionalInt(ANYTHING_SCHEMA_INT_FIELD)
                .optionalLong(ANYTHING_SCHEMA_LONG_FIELD)
                .optionalString(ANYTHING_SCHEMA_STRING_FIELD)
                .name(ANYTHING_SCHEMA_ARRAY_FIELD).type().optional().array()
                .items().type(AVRO_TYPE_ANYTHING)
                .name(ANYTHING_SCHEMA_MAP_FIELD).type().optional().array()
                .items().record(MAP_ENTRY_TYPE_NAME).namespace(NAMESPACE).fields()
                .name(KEY_FIELD).type(AVRO_TYPE_ANYTHING).noDefault()
                .name(VALUE_FIELD).type(AVRO_TYPE_ANYTHING).noDefault()
                .endRecord()
                .endRecord();

        ANYTHING_SCHEMA_MAP_ELEMENT = ANYTHING_SCHEMA.getField("map").schema()
                .getTypes().get(1)
                .getElementType();
    }

    static {
        TO_CONNECT_LOGICAL_CONVERTERS.put(Decimal.LOGICAL_NAME, new LogicalTypeConverter() {
            @Override
            public Object convert(Schema schema, Object value) {
                if (value instanceof byte[]) {
                    return Decimal.toLogical(schema, (byte[]) value);
                } else if (value instanceof ByteBuffer) {
                    return Decimal.toLogical(schema, ((ByteBuffer) value).array());
                }
                throw new ConnectException(
                        "Invalid type for Decimal, underlying representation should be bytes but was "
                                + value.getClass());
            }
        });

        TO_CONNECT_LOGICAL_CONVERTERS.put(Date.LOGICAL_NAME, new LogicalTypeConverter() {
            @Override
            public Object convert(Schema schema, Object value) {
                if (!(value instanceof Integer)) {
                    throw new ConnectException(
                            "Invalid type for Date, underlying representation should be int32 but was "
                                    + value.getClass());
                }
                return Date.toLogical(schema, (int) value);
            }
        });

        TO_CONNECT_LOGICAL_CONVERTERS.put(Time.LOGICAL_NAME, new LogicalTypeConverter() {
            @Override
            public Object convert(Schema schema, Object value) {
                if (!(value instanceof Integer)) {
                    throw new ConnectException(
                            "Invalid type for Time, underlying representation should be int32 but was "
                                    + value.getClass());
                }
                return Time.toLogical(schema, (int) value);
            }
        });

        TO_CONNECT_LOGICAL_CONVERTERS.put(Timestamp.LOGICAL_NAME, new LogicalTypeConverter() {
            @Override
            public Object convert(Schema schema, Object value) {
                if (!(value instanceof Long)) {
                    throw new ConnectException(
                            "Invalid type for Timestamp, underlying representation should be int64 but was "
                                    + value.getClass());
                }
                return Timestamp.toLogical(schema, (long) value);
            }
        });
    }

    static {
        TO_AVRO_LOGICAL_CONVERTERS.put(Decimal.LOGICAL_NAME, new LogicalTypeConverter() {
            @Override
            public Object convert(Schema schema, Object value) {
                if (!(value instanceof BigDecimal)) {
                    throw new ConnectException(
                            "Invalid type for Decimal, expected BigDecimal but was " + value.getClass());
                }
                return Decimal.fromLogical(schema, (BigDecimal) value);
            }
        });
        TO_AVRO_LOGICAL_CONVERTERS.put(Date.LOGICAL_NAME, new LogicalTypeConverter() {
            @Override
            public Object convert(Schema schema, Object value) {
                if (!(value instanceof java.util.Date)) {
                    throw new ConnectException(
                            "Invalid type for Date, expected Date but was " + value.getClass());
                }
                return Date.fromLogical(schema, (java.util.Date) value);
            }
        });

        TO_AVRO_LOGICAL_CONVERTERS.put(Time.LOGICAL_NAME, new LogicalTypeConverter() {
            @Override
            public Object convert(Schema schema, Object value) {
                if (!(value instanceof java.util.Date)) {
                    throw new ConnectException(
                            "Invalid type for Time, expected Date but was " + value.getClass());
                }
                return Time.fromLogical(schema, (java.util.Date) value);
            }
        });

        TO_AVRO_LOGICAL_CONVERTERS.put(Timestamp.LOGICAL_NAME, new LogicalTypeConverter() {
            @Override
            public Object convert(Schema schema, Object value) {
                if (!(value instanceof java.util.Date)) {
                    throw new ConnectException(
                            "Invalid type for Timestamp, expected Date but was " + value.getClass());
                }
                return Timestamp.fromLogical(schema, (java.util.Date) value);
            }
        });
    }

    private final boolean connectMetaData;
    private final boolean enhancedSchemaSupport;
    private final Map<Schema, org.apache.avro.Schema> fromConnectSchemaCache;
    private final Map<AvroSchema, Schema> toConnectSchemaCache;


    public AvroData(AvroDataConfig avroDataConfig) {
        fromConnectSchemaCache = Collections.synchronizedMap(new LinkedHashMap<Schema, org.apache.avro.Schema>(300, 1.1F, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<Schema, org.apache.avro.Schema> eldest) {
                return this.size() > avroDataConfig.getSchemasCacheSize();
            }
        });
        toConnectSchemaCache = Collections.synchronizedMap(new LinkedHashMap<AvroSchema, Schema>(300, 1.1F, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry<AvroSchema, Schema> eldest) {
                return this.size() > avroDataConfig.getSchemasCacheSize();
            }
        });

        this.connectMetaData = avroDataConfig.isConnectMetaData();
        this.enhancedSchemaSupport = avroDataConfig.isEnhancedAvroSchemaSupport();
    }

    private static Object fromConnectData(
            Schema schema,
            org.apache.avro.Schema avroSchema,
            Object data,
            boolean requireContainer,
            boolean requireSchemalessContainerNull,
            boolean enhancedSchemaSupport
    ) {
        FieldType schemaType = schema != null
                ? schema.getFieldType()
                : schemaTypeForSchemalessJavaType(data);

        if (schemaType == null && requireSchemalessContainerNull) {
            if (requireSchemalessContainerNull) {
                return new GenericRecordBuilder(ANYTHING_SCHEMA).build();
            } else {
                return null;
            }
        }

        if (data == null && schema != null && !schema.isOptional()) {
            throw new ConnectException("Found null value for non-optional schema");
        }

        if (data == null) {
            if (schema == null && requireSchemalessContainerNull) {
                return new GenericRecordBuilder(ANYTHING_SCHEMA).build();
            } else {
                return null;
            }
        }

        Object value = data;
        if (schema != null && schema.getName() != null) {
            // Convert logical type data
            LogicalTypeConverter logicalConverter = TO_AVRO_LOGICAL_CONVERTERS.get(schema.getName());
            if (logicalConverter != null) {
                value = logicalConverter.convert(schema, value);
            }
        }

        try {
            switch (schemaType) {
                case INT8: {
                    Byte byteValue = (Byte) value;
                    Integer convertedByteValue = byteValue == null ? null : byteValue.intValue();
                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, convertedByteValue, ANYTHING_SCHEMA_INT_FIELD),
                            requireContainer);
                }
                case INT16: {
                    Short shortValue = (Short) value;
                    Integer convertedShortValue = shortValue == null ? null : shortValue.intValue();
                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, convertedShortValue, ANYTHING_SCHEMA_INT_FIELD),
                            requireContainer);
                }

                case INT32:
                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, value, ANYTHING_SCHEMA_INT_FIELD),
                            requireContainer);
                case INT64:
                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, value, ANYTHING_SCHEMA_LONG_FIELD),
                            requireContainer);
                case FLOAT32:
                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, value, ANYTHING_SCHEMA_FLOAT_FIELD),
                            requireContainer);
                case FLOAT64:
                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, value, ANYTHING_SCHEMA_DOUBLE_FIELD),
                            requireContainer);
                case BOOLEAN:
                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, value, ANYTHING_SCHEMA_BOOLEAN_FIELD),
                            requireContainer);
                case STRING:
                    if (enhancedSchemaSupport && schema != null && schema.getParameters() != null && schema.getParameters().containsKey(AVRO_TYPE_ENUM)) {
                        String enumSchemaName = schema.getParameters().get(AVRO_TYPE_ENUM);
                        org.apache.avro.Schema enumSchema;
                        if (avroSchema.getType() == org.apache.avro.Schema.Type.UNION) {
                            int enumIndex = avroSchema.getIndexNamed(enumSchemaName);
                            enumSchema = avroSchema.getTypes().get(enumIndex);
                        } else {
                            enumSchema = avroSchema;
                        }
                        value = new GenericData.EnumSymbol(enumSchema, (String) value);
                    }

                    // string or enum
                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, value, ANYTHING_SCHEMA_STRING_FIELD),
                            requireContainer);

                case BYTES: {
                    value = value instanceof byte[]
                            ? ByteBuffer.wrap((byte[]) value)
                            : (ByteBuffer) value;
                    if (schema != null && isFixedSchema(schema)) {
                        //Get fixed size
                        int size = Integer.parseInt(schema.getParameters().get(CONNECT_AVRO_FIXED_SIZE));
                        org.apache.avro.Schema fixedSchema = null;
                        if (avroSchema.getType() == org.apache.avro.Schema.Type.UNION) {
                            // union
                            for (org.apache.avro.Schema memberSchema : avroSchema.getTypes()) {
                                if (memberSchema.getType() == org.apache.avro.Schema.Type.FIXED
                                        && memberSchema.getFixedSize() == size
                                        && unionMemberFieldName(memberSchema, enhancedSchemaSupport)
                                        .equals(schema.getName())) {
                                    fixedSchema = memberSchema;
                                }
                            }

                            if (fixedSchema == null) {
                                throw new ConnectException("Fixed size " + size + " not in union " + avroSchema);
                            }
                        } else {
                            fixedSchema = avroSchema;
                        }
                        value = new GenericData.Fixed(fixedSchema, ((ByteBuffer) value).array());
                    }

                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, value, ANYTHING_SCHEMA_BYTES_FIELD),
                            requireContainer);
                }

                case ARRAY: {
                    Collection<Object> list = (Collection<Object>) value;
                    List<Object> converted = new ArrayList<>(list.size());
                    Schema elementSchema = schema != null ? schema.getValueSchema() : null;
                    org.apache.avro.Schema underlyingAvroSchema = avroSchemaForUnderlyingTypeIfOptional(schema, avroSchema);
                    org.apache.avro.Schema elementAvroSchema = schema != null ? underlyingAvroSchema.getElementType() : ANYTHING_SCHEMA;
                    for (Object val : list) {
                        converted.add(
                                fromConnectData(
                                        elementSchema,
                                        elementAvroSchema,
                                        val,
                                        false,
                                        true,
                                        enhancedSchemaSupport
                                )
                        );
                    }
                    return maybeAddContainer(
                            avroSchema,
                            maybeWrapSchemaless(schema, converted, ANYTHING_SCHEMA_ARRAY_FIELD),
                            requireContainer);
                }

                case MAP: {
                    Map<Object, Object> map = (Map<Object, Object>) value;
                    org.apache.avro.Schema underlyingAvroSchema;
                    if (schema != null
                            && schema.getKeySchema().getFieldType() == FieldType.STRING
                            && !schema.getKeySchema().isOptional()) {

                        // TODO most types don't need a new converted object since types pass through
                        underlyingAvroSchema = avroSchemaForUnderlyingTypeIfOptional(schema, avroSchema);
                        Map<String, Object> converted = new HashMap<>();
                        for (Map.Entry<Object, Object> entry : map.entrySet()) {
                            // Key is a String, no conversion needed
                            Object convertedValue = fromConnectData(
                                    schema.getValueSchema(),
                                    underlyingAvroSchema.getValueType(),
                                    entry.getValue(),
                                    false,
                                    true,
                                    enhancedSchemaSupport
                            );
                            converted.put((String) entry.getKey(), convertedValue);
                        }
                        return maybeAddContainer(avroSchema, converted, requireContainer);
                    } else {
                        List<GenericRecord> converted = new ArrayList<>(map.size());
                        underlyingAvroSchema = avroSchemaForUnderlyingMapEntryType(schema, avroSchema);
                        org.apache.avro.Schema elementSchema =
                                schema != null
                                        ? underlyingAvroSchema.getElementType()
                                        : ANYTHING_SCHEMA_MAP_ELEMENT;
                        org.apache.avro.Schema avroKeySchema = elementSchema.getField(KEY_FIELD).schema();
                        org.apache.avro.Schema avroValueSchema = elementSchema.getField(VALUE_FIELD).schema();
                        for (Map.Entry<Object, Object> entry : map.entrySet()) {
                            Object keyConverted = fromConnectData(schema != null ? schema.getKeySchema() : null,
                                    avroKeySchema, entry.getKey(), false, true,
                                    enhancedSchemaSupport);
                            Object valueConverted = fromConnectData(schema != null ? schema.getValueSchema() : null,
                                    avroValueSchema, entry.getValue(), false,
                                    true, enhancedSchemaSupport);
                            converted.add(
                                    new GenericRecordBuilder(elementSchema)
                                            .set(KEY_FIELD, keyConverted)
                                            .set(VALUE_FIELD, valueConverted)
                                            .build()
                            );
                        }
                        return maybeAddContainer(
                                avroSchema, maybeWrapSchemaless(schema, converted, ANYTHING_SCHEMA_MAP_FIELD),
                                requireContainer);
                    }
                }

                case STRUCT: {
                    Struct struct = (Struct) value;
                    if (!struct.schema().equals(schema)) {
                        throw new ConnectException("Mismatching struct schema");
                    }
                    //This handles the inverting of a union which is held as a struct, where each field is
                    // one of the union types.
                    if (AVRO_TYPE_UNION.equals(schema.getName())) {
                        for (Field field : schema.getFields()) {
                            Object object = struct.get(field);
                            if (object != null) {
                                return fromConnectData(
                                        field.getSchema(),
                                        avroSchema,
                                        object,
                                        false,
                                        true,
                                        enhancedSchemaSupport
                                );
                            }
                        }
                        return fromConnectData(schema, avroSchema, null, false, true, enhancedSchemaSupport);
                    } else {
                        org.apache.avro.Schema underlyingAvroSchema = avroSchemaForUnderlyingTypeIfOptional(
                                schema, avroSchema);
                        GenericRecordBuilder convertedBuilder = new GenericRecordBuilder(underlyingAvroSchema);
                        for (Field field : schema.getFields()) {
                            org.apache.avro.Schema.Field theField = underlyingAvroSchema.getField(field.getName());
                            org.apache.avro.Schema fieldAvroSchema = theField.schema();
                            convertedBuilder.set(
                                    field.getName(),
                                    fromConnectData(field.getSchema(), fieldAvroSchema, struct.get(field), false,
                                            true, enhancedSchemaSupport)
                            );
                        }
                        return convertedBuilder.build();
                    }
                }
                default:
                    throw new ConnectException("Unknown schema type: " + schema.getFieldType());
            }
        } catch (ClassCastException e) {
            throw new ConnectException("Invalid type for " + schema.getFieldType() + ": " + value.getClass());
        }
    }

    /**
     * MapEntry types in connect Schemas are represented as Arrays of record.
     * Return the array type from the union instead of the union itself.
     */
    private static org.apache.avro.Schema avroSchemaForUnderlyingMapEntryType(
            Schema schema,
            org.apache.avro.Schema avroSchema) {

        if (schema != null && schema.isOptional()) {
            if (avroSchema.getType() == org.apache.avro.Schema.Type.UNION) {
                for (org.apache.avro.Schema typeSchema : avroSchema.getTypes()) {
                    if (!typeSchema.getType().equals(org.apache.avro.Schema.Type.NULL)
                            && org.apache.avro.Schema.Type.ARRAY.getName().equals(typeSchema.getType().getName())) {
                        return typeSchema;
                    }
                }
            } else {
                throw new ConnectException(
                        "An optional schema should have an Avro Union type, not "
                                + schema.getFieldType());
            }
        }
        return avroSchema;
    }

    private static boolean crossReferenceSchemaNames(final Schema schema,
                                                     final org.apache.avro.Schema avroSchema) {
        return Objects.equals(avroSchema.getFullName(), schema.getName())
                || Objects.equals(avroSchema.getType().getName(), schema.getFieldType().name().toLowerCase())
                || (schema.getName() == null && avroSchema.getFullName().equals(DEFAULT_SCHEMA_FULL_NAME));
    }

    /**
     * Connect optional fields are represented as a unions (null & type) in Avro
     * Return the Avro schema of the actual type in the Union (instead of the union itself)
     */
    private static org.apache.avro.Schema avroSchemaForUnderlyingTypeIfOptional(Schema schema, org.apache.avro.Schema avroSchema) {

        if (schema != null && schema.isOptional()) {
            if (avroSchema.getType() == org.apache.avro.Schema.Type.UNION) {
                for (org.apache.avro.Schema typeSchema : avroSchema.getTypes()) {
                    if (!typeSchema.getType().equals(org.apache.avro.Schema.Type.NULL)
                            && crossReferenceSchemaNames(schema, typeSchema)) {
                        return typeSchema;
                    }
                }
            } else {
                throw new ConnectException(
                        "An optional schema should have an Avro Union type, not "
                                + schema.getFieldType());
            }
        }
        return avroSchema;
    }

    private static FieldType schemaTypeForSchemalessJavaType(Object value) {
        if (value == null) {
            return null;
        } else if (value instanceof Byte) {
            return FieldType.INT8;
        } else if (value instanceof Short) {
            return FieldType.INT16;
        } else if (value instanceof Integer) {
            return FieldType.INT32;
        } else if (value instanceof Long) {
            return FieldType.INT64;
        } else if (value instanceof Float) {
            return FieldType.FLOAT32;
        } else if (value instanceof Double) {
            return FieldType.FLOAT64;
        } else if (value instanceof Boolean) {
            return FieldType.BOOLEAN;
        } else if (value instanceof String) {
            return FieldType.STRING;
        } else if (value instanceof Collection) {
            return FieldType.ARRAY;
        } else if (value instanceof Map) {
            return FieldType.MAP;
        } else {
            throw new ConnectException("Unknown Java type for schemaless data: " + value.getClass());
        }
    }

    private static Object maybeAddContainer(org.apache.avro.Schema avroSchema, Object value, boolean wrap) {
        return wrap ? new NonRecordContainer(avroSchema, value) : value;
    }

    private static Object maybeWrapSchemaless(Schema schema, Object value, String typeField) {
        if (schema != null) {
            return value;
        }

        GenericRecordBuilder builder = new GenericRecordBuilder(ANYTHING_SCHEMA);
        if (value != null) {
            builder.set(typeField, value);
        }
        return builder.build();
    }

    /**
     * to avro logical
     *
     * @param schema
     * @param value
     * @return
     */
    private static Object toAvroLogical(Schema schema, Object value) {
        if (schema != null && schema.getName() != null) {
            LogicalTypeConverter logicalConverter = TO_AVRO_LOGICAL_CONVERTERS.get(schema.getName());
            if (logicalConverter != null && value != null) {
                return logicalConverter.convert(schema, value);
            }
        }
        return value;
    }

    private static Object toConnectLogical(Schema schema, Object value) {
        if (schema != null && schema.getName() != null) {
            LogicalTypeConverter logicalConverter = TO_CONNECT_LOGICAL_CONVERTERS.get(schema.getName());
            if (logicalConverter != null && value != null) {
                return logicalConverter.convert(schema, value);
            }
        }
        return value;
    }

    // Convert default values from Connect data format to Avro's format, which is an
    // org.codehaus.jackson.JsonNode. The default value is provided as an argument because even
    // though you can get a default value from the schema, default values for complex structures need
    // to perform the same translation but those defaults will be part of the original top-level
    // (complex type) default value, not part of the child schema.
    private static JsonNode defaultValueFromConnect(Schema schema, Object value) {
        try {
            // If this is a logical type, convert it from the convenient Java type to the underlying
            // serializeable format
            Object defaultVal = toAvroLogical(schema, value);

            switch (schema.getFieldType()) {
                case INT8:
                    return JsonNodeFactory.instance.numberNode(((Byte) defaultVal).intValue());
                case INT16:
                    return JsonNodeFactory.instance.numberNode(((Short) defaultVal).intValue());
                case INT32:
                    return JsonNodeFactory.instance.numberNode((Integer) defaultVal);
                case INT64:
                    return JsonNodeFactory.instance.numberNode((Long) defaultVal);
                case FLOAT32:
                    return JsonNodeFactory.instance.numberNode((Float) defaultVal);
                case FLOAT64:
                    return JsonNodeFactory.instance.numberNode((Double) defaultVal);
                case BOOLEAN:
                    return JsonNodeFactory.instance.booleanNode((Boolean) defaultVal);
                case STRING:
                    return JsonNodeFactory.instance.textNode((String) defaultVal);
                case BYTES:
                    if (defaultVal instanceof byte[]) {
                        return JsonNodeFactory.instance.textNode(new String((byte[]) defaultVal,
                                StandardCharsets.ISO_8859_1));
                    } else {
                        return JsonNodeFactory.instance.textNode(new String(((ByteBuffer) defaultVal).array(),
                                StandardCharsets.ISO_8859_1));
                    }
                case ARRAY: {
                    ArrayNode array = JsonNodeFactory.instance.arrayNode();
                    for (Object elem : (Collection<Object>) defaultVal) {
                        array.add(defaultValueFromConnect(schema.getValueSchema(), elem));
                    }
                    return array;
                }
                case MAP:
                    if (schema.getKeySchema().getFieldType() == FieldType.STRING && !schema.getKeySchema().isOptional()) {
                        ObjectNode node = JsonNodeFactory.instance.objectNode();
                        for (Map.Entry<String, Object> entry : ((Map<String, Object>) defaultVal).entrySet()) {
                            JsonNode entryDef = defaultValueFromConnect(schema.getValueSchema(), entry.getValue());
                            node.put(entry.getKey(), entryDef);
                        }
                        return node;
                    } else {
                        ArrayNode array = JsonNodeFactory.instance.arrayNode();
                        for (Map.Entry<Object, Object> entry : ((Map<Object, Object>) defaultVal).entrySet()) {
                            JsonNode keyDefault = defaultValueFromConnect(schema.getKeySchema(), entry.getKey());
                            JsonNode valDefault = defaultValueFromConnect(schema.getValueSchema(), entry.getValue());
                            ArrayNode jsonEntry = JsonNodeFactory.instance.arrayNode();
                            jsonEntry.add(keyDefault);
                            jsonEntry.add(valDefault);
                            array.add(jsonEntry);
                        }
                        return array;
                    }
                case STRUCT: {
                    ObjectNode node = JsonNodeFactory.instance.objectNode();
                    Struct struct = (Struct) defaultVal;
                    for (Field field : schema.getFields()) {
                        JsonNode fieldDef = defaultValueFromConnect(field.getSchema(), struct.get(field));
                        node.set(field.getName(), fieldDef);
                    }
                    return node;
                }
                default:
                    throw new ConnectException("Unknown schema type:" + schema.getFieldType());
            }
        } catch (ClassCastException e) {
            throw new ConnectException("Invalid type used for default value of "
                    + schema.getFieldType()
                    + " field: "
                    + schema.getDefaultValue().getClass());
        }
    }

    private static JsonNode parametersFromConnect(Map<String, String> params) {
        ObjectNode result = JsonNodeFactory.instance.objectNode();
        for (Map.Entry<String, String> entry : params.entrySet()) {
            if (!entry.getKey().equals(AVRO_FIELD_DEFAULT_FLAG)) {
                result.put(entry.getKey(), entry.getValue());
            }
        }
        return result;
    }

    private static String unionMemberFieldName(org.apache.avro.Schema schema,
                                               boolean enhancedSchemaSupport) {
        if (schema.getType() == org.apache.avro.Schema.Type.RECORD
                || schema.getType() == org.apache.avro.Schema.Type.ENUM
                || schema.getType() == org.apache.avro.Schema.Type.FIXED) {
            if (enhancedSchemaSupport) {
                return schema.getFullName();
            } else {
                return splitName(schema.getName())[1];
            }
        }
        return schema.getType().getName();
    }

    private static String unionMemberFieldName(Schema schema, boolean enhancedSchemaSupport) {
        if (schema.getFieldType() == FieldType.STRUCT || isEnumSchema(schema)) {
            if (enhancedSchemaSupport) {
                return schema.getName();
            } else {
                return splitName(schema.getName())[1];
            }
        } else {
            if (isFixedSchema(schema)) {
                return schema.getName();
            }
        }
        return CONNECT_TYPES_TO_AVRO_TYPES.get(schema.getFieldType()).getName();
    }

    private static boolean isEnumSchema(Schema schema) {
        return schema.getFieldType() == FieldType.STRING
                && schema.getParameters() != null
                && schema.getParameters().containsKey(AVRO_TYPE_ENUM);
    }

    private static boolean isFixedSchema(Schema schema) {
        return schema.getFieldType() == FieldType.BYTES
                && schema.getName() != null
                && schema.getParameters() != null
                && schema.getParameters().containsKey(CONNECT_AVRO_FIXED_SIZE);
    }

    private static boolean isInstanceOfAvroSchemaTypeForSimpleSchema(Schema fieldSchema,
                                                                     Object value,
                                                                     boolean enhancedSchemaSupport) {
        if (isEnumSchema(fieldSchema)) {
            String enumSchemaName = fieldSchema.getParameters().get(AVRO_TYPE_ENUM);
            if (value instanceof GenericData.EnumSymbol) {
                return ((GenericData.EnumSymbol) value).getSchema().getFullName().equals(enumSchemaName);
            } else {
                return value.getClass().getName().equals(enumSchemaName);
            }
        }
        List<Class> classes = SIMPLE_AVRO_SCHEMA_TYPES.get(fieldSchema.getFieldType());
        if (classes == null) {
            return false;
        }
        for (Class type : classes) {
            if (type.isInstance(value)) {
                if (isFixedSchema(fieldSchema)) {
                    if (fixedValueSizeMatch(fieldSchema, value,
                            Integer.parseInt(fieldSchema.getParameters().get(CONNECT_AVRO_FIXED_SIZE)),
                            enhancedSchemaSupport)) {
                        return true;
                    }
                } else {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * Returns true if the fixed value size of the value matches the expected size
     */
    private static boolean fixedValueSizeMatch(Schema fieldSchema,
                                               Object value,
                                               int size,
                                               boolean enhancedSchemaSupport) {
        if (value instanceof byte[]) {
            return ((byte[]) value).length == size;
        } else if (value instanceof ByteBuffer) {
            return ((ByteBuffer) value).remaining() == size;
        } else if (value instanceof GenericFixed) {
            return unionMemberFieldName(((GenericFixed) value).getSchema(), enhancedSchemaSupport)
                    .equals(fieldSchema.getName());
        } else {
            throw new ConnectException("Invalid class for fixed, expecting GenericFixed, byte[]"
                    + " or ByteBuffer but found " + value.getClass());
        }
    }

    /**
     * Split a full dotted-syntax name into a namespace and a single-component name.
     */
    private static String[] splitName(String fullName) {
        String[] result = new String[2];
        int indexLastDot = fullName.lastIndexOf('.');
        if (indexLastDot >= 0) {
            result[0] = fullName.substring(0, indexLastDot);
            result[1] = fullName.substring(indexLastDot + 1);
        } else {
            result[0] = null;
            result[1] = fullName;
        }
        return result;
    }

    public static Schema nonOptional(Schema schema) {
        return new Schema(
                schema.getName(),
                schema.getFieldType(),
                false,
                schema.getDefaultValue(),
                schema.getVersion(),
                schema.getDoc(),
                fields(schema),
                keySchema(schema),
                valueSchema(schema),
                schema.getParameters()
        );
    }

    public static List<Field> fields(Schema schema) {
        FieldType type = schema.getFieldType();
        if (FieldType.STRUCT.equals(type)) {
            return schema.getFields();
        } else {
            return null;
        }
    }

    public static Schema keySchema(Schema schema) {
        FieldType type = schema.getFieldType();
        if (FieldType.MAP.equals(type)) {
            return schema.getKeySchema();
        } else {
            return null;
        }
    }

    public static Schema valueSchema(Schema schema) {
        FieldType type = schema.getFieldType();
        if (FieldType.MAP.equals(type) || FieldType.ARRAY.equals(type)) {
            return schema.getValueSchema();
        } else {
            return null;
        }
    }

    private static boolean fieldListEquals(List<Field> one, List<Field> two,
                                           Map<SchemaPair, Boolean> cache) {
        if (one == two) {
            return true;
        } else if (one == null || two == null) {
            return false;
        } else {
            ListIterator<Field> itOne = one.listIterator();
            ListIterator<Field> itTwo = two.listIterator();
            while (itOne.hasNext() && itTwo.hasNext()) {
                if (!fieldEquals(itOne.next(), itTwo.next(), cache)) {
                    return false;
                }
            }
            return itOne.hasNext() == itTwo.hasNext();
        }
    }

    private static boolean fieldEquals(Field one, Field two, Map<SchemaPair, Boolean> cache) {
        if (one == two) {
            return true;
        } else if (one == null || two == null) {
            return false;
        } else {
            return one.getClass() == two.getClass()
                    && Objects.equals(one.getIndex(), two.getIndex())
                    && Objects.equals(one.getName(), two.getName())
                    && schemaEquals(one.getSchema(), two.getSchema(), cache);
        }
    }

    private static boolean schemaEquals(Schema src, Schema that) {
        return schemaEquals(src, that, new HashMap<>());
    }

    private static boolean schemaEquals(Schema src, Schema that, Map<SchemaPair, Boolean> cache) {
        if (src == that) {
            return true;
        } else if (src == null || that == null) {
            return false;
        }

        SchemaPair sp = new SchemaPair(src, that);
        Boolean cacheHit = cache.putIfAbsent(sp, true);
        if (cacheHit != null) {
            return cacheHit;
        }

        boolean equals = Objects.equals(src.isOptional(), that.isOptional())
                && Objects.equals(src.getVersion(), that.getVersion())
                && Objects.equals(src.getName(), that.getName())
                && Objects.equals(src.getDoc(), that.getDoc())
                && Objects.equals(src.getFieldType(), that.getFieldType())
                && Objects.deepEquals(src.getDefaultValue(), that.getDefaultValue())
                && Objects.equals(src.getParameters(), that.getParameters());

        switch (src.getFieldType()) {
            case STRUCT:
                equals = equals && fieldListEquals(src.getFields(), that.getFields(), cache);
                break;
            case ARRAY:
                equals = equals && schemaEquals(src.getValueSchema(), that.getValueSchema(), cache);
                break;
            case MAP:
                equals = equals
                        && schemaEquals(src.getValueSchema(), that.getValueSchema(), cache)
                        && schemaEquals(src.getKeySchema(), that.getKeySchema(), cache);
                break;
            default:
                break;
        }
        cache.put(sp, equals);
        return equals;
    }

    /**
     * Convert this object, in Connect data format, into an Avro object.
     */
    public Object fromConnectData(Schema schema, Object value) {
        org.apache.avro.Schema avroSchema = fromConnectSchema(schema);
        return fromConnectData(schema, avroSchema, value);
    }

    protected Object fromConnectData(Schema schema, org.apache.avro.Schema avroSchema, Object value) {
        return fromConnectData(schema, avroSchema, value, true, false, enhancedSchemaSupport);
    }

    public org.apache.avro.Schema fromConnectSchema(Schema schema) {
        return fromConnectSchema(schema, new HashMap<>());
    }

    public org.apache.avro.Schema fromConnectSchema(Schema schema,
                                                    Map<Schema, org.apache.avro.Schema> schemaMap) {
        FromConnectContext fromConnectContext = new FromConnectContext(schemaMap);
        return fromConnectSchema(schema, fromConnectContext, false);
    }

    public org.apache.avro.Schema fromConnectSchema(Schema schema,
                                                    FromConnectContext fromConnectContext,
                                                    boolean ignoreOptional) {
        if (schema == null) {
            return ANYTHING_SCHEMA;
        }

        org.apache.avro.Schema cached = fromConnectSchemaCache.get(schema);

        if (cached == null && !AVRO_TYPE_UNION.equals(schema.getName()) && !schema.isOptional()) {
            cached = fromConnectContext.schemaMap.get(schema);
        }
        if (cached != null) {
            return cached;
        }

        /**
         * parse namespace and name
         */
        String namespace = NAMESPACE;
        String name = DEFAULT_SCHEMA_NAME;
        if (schema.getName() != null) {
            String[] split = splitName(schema.getName());
            name = split[1];
            namespace = split[0];
        }

        String connectType = null;
        final org.apache.avro.Schema baseSchema;
        switch (schema.getFieldType()) {
            case INT8:
                connectType = CONNECT_TYPE_INT8;
                baseSchema = org.apache.avro.SchemaBuilder.builder().intType();
                break;
            case INT16:
                connectType = CONNECT_TYPE_INT16;
                baseSchema = org.apache.avro.SchemaBuilder.builder().intType();
                break;
            case INT32:
                baseSchema = org.apache.avro.SchemaBuilder.builder().intType();
                break;
            case INT64:
                baseSchema = org.apache.avro.SchemaBuilder.builder().longType();
                break;
            case FLOAT32:
                baseSchema = org.apache.avro.SchemaBuilder.builder().floatType();
                break;
            case FLOAT64:
                baseSchema = org.apache.avro.SchemaBuilder.builder().doubleType();
                break;
            case BOOLEAN:
                baseSchema = org.apache.avro.SchemaBuilder.builder().booleanType();
                break;
            case STRING:
                // enum type
                if (enhancedSchemaSupport && schema.getParameters() != null && schema.getParameters().containsKey(AVRO_TYPE_ENUM)) {
                    List<String> symbols = new ArrayList<>();
                    for (Map.Entry<String, String> entry : schema.getParameters().entrySet()) {
                        if (entry.getKey().startsWith(AVRO_TYPE_ENUM + ".")) {
                            symbols.add(entry.getValue());
                        }
                    }
                    String enumDoc = schema.getParameters().get(AVRO_ENUM_DOC_PREFIX + name);
                    String enumDefault = schema.getParameters().get(AVRO_ENUM_DEFAULT_PREFIX + name);
                    baseSchema = org.apache.avro.SchemaBuilder.builder()
                            .enumeration(schema.getParameters().get(AVRO_TYPE_ENUM))
                            .doc(enumDoc)
                            .defaultSymbol(enumDefault)
                            .symbols(symbols.toArray(new String[0]));
                } else {
                    // common string
                    baseSchema = org.apache.avro.SchemaBuilder.builder().stringType();
                }
                break;
            case BYTES:
                //build convert schema type
                if (isFixedSchema(schema)) {
                    baseSchema = org.apache.avro.SchemaBuilder.builder()
                            .fixed(schema.getName())
                            .namespace(namespace)
                            .size(Integer.parseInt(schema.getParameters().get(CONNECT_AVRO_FIXED_SIZE)));
                } else {
                    baseSchema = org.apache.avro.SchemaBuilder.builder().bytesType();
                }
                // logical type decimal
                if (Decimal.LOGICAL_NAME.equalsIgnoreCase(schema.getName())) {
                    Map<String, String> parameters = schema.getParameters();
                    int scale = Integer.parseInt(parameters.get(Decimal.SCALE_FIELD));
                    baseSchema.addProp(AVRO_LOGICAL_DECIMAL_SCALE_PROP, new IntNode(scale));
                    if (parameters.containsKey(CONNECT_AVRO_DECIMAL_PRECISION_PROP)) {
                        String precisionValue = parameters.get(CONNECT_AVRO_DECIMAL_PRECISION_PROP);
                        int precision = Integer.parseInt(precisionValue);
                        baseSchema.addProp(AVRO_LOGICAL_DECIMAL_PRECISION_PROP, new IntNode(precision));
                    } else {
                        baseSchema.addProp(
                                AVRO_LOGICAL_DECIMAL_PRECISION_PROP,
                                new IntNode(CONNECT_AVRO_DECIMAL_PRECISION_DEFAULT)
                        );
                    }
                }
                break;

            case ARRAY:
                baseSchema = org.apache.avro.SchemaBuilder
                        .builder()
                        .array()
                        .items(fromConnectSchemaWithCycle(schema.getValueSchema(), fromConnectContext, false));
                break;
            case MAP:
                // Avro only supports string keys, so we match the representation when possible, but otherwise fall back on a record representation
                if (schema.getKeySchema().getFieldType() == FieldType.STRING
                        && !schema.getKeySchema().isOptional()) {
                    baseSchema = org.apache.avro.SchemaBuilder.builder()
                            .map()
                            .values(fromConnectSchemaWithCycle(schema.getValueSchema(), fromConnectContext, false));
                } else {
                    // Special record name indicates format
                    List<org.apache.avro.Schema.Field> fields = new ArrayList<>();
                    final org.apache.avro.Schema mapSchema;
                    if (schema.getName() == null) {
                        mapSchema = org.apache.avro.Schema.createRecord(
                                MAP_ENTRY_TYPE_NAME,
                                null,
                                namespace,
                                false
                        );
                    } else {
                        mapSchema = org.apache.avro.Schema.createRecord(name, null, namespace, false);
                        mapSchema.addProp(CONNECT_INTERNAL_TYPE_NAME, MAP_ENTRY_TYPE_NAME);
                    }
                    addAvroRecordField(
                            fields,
                            KEY_FIELD,
                            schema.getKeySchema(),
                            schema.getKeySchema().getDoc(),
                            fromConnectContext);
                    addAvroRecordField(
                            fields,
                            VALUE_FIELD,
                            schema.getValueSchema(),
                            schema.getValueSchema().getDoc(),
                            fromConnectContext);
                    mapSchema.setFields(fields);
                    baseSchema = org.apache.avro.Schema.createArray(mapSchema);
                }
                break;
            case STRUCT:
                if (AVRO_TYPE_UNION.equals(schema.getName())) {
                    List<org.apache.avro.Schema> unionSchemas = new ArrayList<>();
                    if (schema.isOptional()) {
                        unionSchemas.add(org.apache.avro.SchemaBuilder.builder().nullType());
                    }
                    for (Field field : schema.getFields()) {
                        unionSchemas.add(
                                fromConnectSchemaWithCycle(
                                        nonOptional(field.getSchema()),
                                        fromConnectContext,
                                        true
                                )
                        );
                    }
                    baseSchema = org.apache.avro.Schema.createUnion(unionSchemas);
                } else if (schema.isOptional()) {
                    List<org.apache.avro.Schema> unionSchemas = new ArrayList<>();
                    unionSchemas.add(org.apache.avro.SchemaBuilder.builder().nullType());
                    unionSchemas.add(
                            fromConnectSchemaWithCycle(nonOptional(schema), fromConnectContext, false));
                    baseSchema = org.apache.avro.Schema.createUnion(unionSchemas);
                } else {
                    String doc = schema.getParameters() != null
                            ? schema.getParameters().get(AVRO_RECORD_DOC)
                            : null;
                    baseSchema = org.apache.avro.Schema.createRecord(
                            name != null ? name : DEFAULT_SCHEMA_NAME,
                            doc,
                            namespace,
                            false
                    );

                    if (schema.getName() != null) {
                        fromConnectContext.cycleReferences.put(schema.getName(), baseSchema);
                    }
                    List<org.apache.avro.Schema.Field> fields = new ArrayList<>();
                    for (Field field : schema.getFields()) {
                        String fieldDoc = schema.getParameters() != null
                                ? schema.getParameters().get(AVRO_FIELD_DOC_PREFIX + field.getName())
                                : null;
                        addAvroRecordField(fields, field.getName(), field.getSchema(), fieldDoc, fromConnectContext);
                    }
                    baseSchema.setFields(fields);
                }
                break;
            default:
                throw new ConnectException("Unknown schema type: " + schema.getFieldType());
        }

        org.apache.avro.Schema finalSchema = baseSchema;
        if (!baseSchema.getType().equals(org.apache.avro.Schema.Type.UNION)) {
            if (connectMetaData) {
                if (schema.getDoc() != null) {
                    baseSchema.addProp(CONNECT_DOC, schema.getDoc());
                }
                if (schema.getVersion() != null) {
                    baseSchema.addProp(CONNECT_VERSION, JsonNodeFactory.instance.numberNode(schema.getVersion()));
                }
                if (schema.getParameters() != null) {
                    JsonNode params = parametersFromConnect(schema.getParameters());
                    if (!params.isEmpty()) {
                        baseSchema.addProp(CONNECT_PARAMETERS, params);
                    }
                }
                if (schema.getDefaultValue() != null) {
                    if (schema.getParameters() == null
                            || !schema.getParameters().containsKey(AVRO_FIELD_DEFAULT_FLAG)) {
                        baseSchema.addProp(CONNECT_DEFAULT_VALUE,
                                defaultValueFromConnect(schema, schema.getDefaultValue()));
                    }
                }
                if (schema.getName() != null) {
                    baseSchema.addProp(CONNECT_NAME, schema.getName());
                }
                // Some Connect types need special annotations to preserve the types accurate due to
                // limitations in Avro. These types get an extra annotation with their Connect type
                if (connectType != null) {
                    baseSchema.addProp(CONNECT_TYPE, connectType);
                }
            }

            boolean forceLegacyDecimal = false;
            // the new and correct way to handle logical types
            if (schema.getName() != null) {
                if (Decimal.LOGICAL_NAME.equalsIgnoreCase(schema.getName())) {
                    String precisionString = schema.getParameters().get(CONNECT_AVRO_DECIMAL_PRECISION_PROP);
                    String scaleString = schema.getParameters().get(Decimal.SCALE_FIELD);
                    int precision = precisionString == null ? CONNECT_AVRO_DECIMAL_PRECISION_DEFAULT :
                            Integer.parseInt(precisionString);
                    int scale = scaleString == null ? 0 : Integer.parseInt(scaleString);
                    if (scale < 0 || scale > precision) {
                        log.trace(
                                "Scale and precision of {} and {} cannot be serialized as native Avro logical "
                                        + "decimal type; reverting to legacy serialization method",
                                scale,
                                precision
                        );
                        forceLegacyDecimal = true;
                    } else {
                        org.apache.avro.LogicalTypes.decimal(precision, scale).addToSchema(baseSchema);
                    }
                } else if (Time.LOGICAL_NAME.equalsIgnoreCase(schema.getName())) {
                    org.apache.avro.LogicalTypes.timeMillis().addToSchema(baseSchema);
                } else if (Timestamp.LOGICAL_NAME.equalsIgnoreCase(schema.getName())) {
                    org.apache.avro.LogicalTypes.timestampMillis().addToSchema(baseSchema);
                } else if (Date.LOGICAL_NAME.equalsIgnoreCase(schema.getName())) {
                    org.apache.avro.LogicalTypes.date().addToSchema(baseSchema);
                }
            }

            if (schema.getName() != null) {
                if (Decimal.LOGICAL_NAME.equalsIgnoreCase(schema.getName())
                        && (schema.getParameters().containsKey(CONNECT_AVRO_DECIMAL_PRECISION_PROP)
                        || forceLegacyDecimal)) {
                    baseSchema.addProp(AVRO_LOGICAL_TYPE_PROP, AVRO_LOGICAL_DECIMAL);
                } else if (Time.LOGICAL_NAME.equalsIgnoreCase(schema.getName())) {
                    baseSchema.addProp(AVRO_LOGICAL_TYPE_PROP, AVRO_LOGICAL_TIME_MILLIS);
                } else if (Timestamp.LOGICAL_NAME.equalsIgnoreCase(schema.getName())) {
                    baseSchema.addProp(AVRO_LOGICAL_TYPE_PROP, AVRO_LOGICAL_TIMESTAMP_MILLIS);
                } else if (Date.LOGICAL_NAME.equalsIgnoreCase(schema.getName())) {
                    baseSchema.addProp(AVRO_LOGICAL_TYPE_PROP, AVRO_LOGICAL_DATE);
                }
            }

            if (schema.getParameters() != null) {
                for (Map.Entry<String, String> entry : schema.getParameters().entrySet()) {
                    if (entry.getKey().startsWith(AVRO_PROP)) {
                        baseSchema.addProp(entry.getKey(), entry.getValue());
                    }
                }
            }

            if (!ignoreOptional) {
                if (schema.isOptional()) {
                    if (schema.getDefaultValue() != null) {
                        finalSchema = org.apache.avro.SchemaBuilder.builder()
                                .unionOf()
                                .type(baseSchema)
                                .and()
                                .nullType()
                                .endUnion();
                    } else {
                        finalSchema = org.apache.avro.SchemaBuilder.builder()
                                .unionOf()
                                .nullType()
                                .and()
                                .type(baseSchema)
                                .endUnion();
                    }
                }
            }
        }

        if (!schema.isOptional()) {
            fromConnectContext.schemaMap.put(schema, finalSchema);
        }
        fromConnectSchemaCache.put(schema, finalSchema);
        return finalSchema;
    }

    public org.apache.avro.Schema fromConnectSchemaWithCycle(
            Schema schema,
            FromConnectContext fromConnectContext,
            boolean ignoreOptional) {
        org.apache.avro.Schema resolvedSchema;
        if (fromConnectContext.cycleReferences.containsKey(schema.getName())) {
            resolvedSchema = fromConnectContext.cycleReferences.get(schema.getName());
        } else {
            resolvedSchema = fromConnectSchema(schema, fromConnectContext, ignoreOptional);
        }
        return resolvedSchema;
    }

    private void addAvroRecordField(
            List<org.apache.avro.Schema.Field> fields,
            String fieldName,
            Schema fieldSchema,
            String fieldDoc,
            FromConnectContext fromConnectContext) {

        Object defaultVal = null;
        if (fieldSchema.getDefaultValue() != null) {
            defaultVal = JacksonUtils.toObject(defaultValueFromConnect(fieldSchema, fieldSchema.getDefaultValue()));
        } else if (fieldSchema.isOptional()) {
            defaultVal = JsonProperties.NULL_VALUE;
        }
        org.apache.avro.Schema.Field field = new org.apache.avro.Schema.Field(
                fieldName,
                fromConnectSchema(fieldSchema, fromConnectContext, false),
                fieldDoc,
                defaultVal);
        fields.add(field);
    }

    private boolean isMapEntry(final org.apache.avro.Schema elemSchema) {
        if (!elemSchema.getType().equals(org.apache.avro.Schema.Type.RECORD)) {
            return false;
        }
        if (NAMESPACE.equals(elemSchema.getNamespace())
                && MAP_ENTRY_TYPE_NAME.equals(elemSchema.getName())) {
            return true;
        }
        return Objects.equals(elemSchema.getProp(CONNECT_INTERNAL_TYPE_NAME), MAP_ENTRY_TYPE_NAME);
    }

    /**
     * Convert the given object, in Avro format, into a Connect data object.
     *
     * @param avroSchema the Avro schema
     * @param value      the value to convert into a Connect data object
     * @return the Connect schema and value
     */
    public SchemaAndValue toConnectData(org.apache.avro.Schema avroSchema, Object value) {
        return toConnectData(avroSchema, value, null);
    }

    /**
     * Convert the given object, in Avro format, into a Connect data object.
     *
     * @param avroSchema the Avro schema
     * @param value      the value to convert into a Connect data object
     * @param version    the version to set on the Connect schema if the avroSchema does not have a
     *                   property named "connect.version", may be null
     * @return the Connect schema and value
     */
    public SchemaAndValue toConnectData(org.apache.avro.Schema avroSchema, Object value,
                                        Integer version) {
        if (value == null) {
            return null;
        }
        ToConnectContext toConnectContext = new ToConnectContext();
        Schema schema = (avroSchema.equals(ANYTHING_SCHEMA))
                ? null
                : toConnectSchema(avroSchema, version, toConnectContext);
        return new SchemaAndValue(schema, toConnectData(schema, value, toConnectContext));
    }

    private Object toConnectData(Schema schema, Object value, ToConnectContext toConnectContext) {
        return toConnectData(schema, value, toConnectContext, true);
    }

    private Object toConnectData(Schema schema,
                                 Object value,
                                 ToConnectContext toConnectContext,
                                 boolean doLogicalConversion) {
        if (value == null && schema != null && !schema.isOptional()) {
            throw new ConnectException("Found null value for non-optional schema");
        }

        if (value == null) {
            return null;
        }
        try {
            // If we're decoding schemaless data, we need to unwrap it into just the single value
            if (schema == null) {
                if (!(value instanceof IndexedRecord)) {
                    throw new ConnectException("Invalid Avro data for schemaless Connect data");
                }
                IndexedRecord recordValue = (IndexedRecord) value;

                Object
                        boolVal =
                        recordValue.get(ANYTHING_SCHEMA.getField(ANYTHING_SCHEMA_BOOLEAN_FIELD).pos());
                if (boolVal != null) {
                    return toConnectData(SchemaBuilder.bool().build(), boolVal, toConnectContext);
                }

                Object
                        bytesVal =
                        recordValue.get(ANYTHING_SCHEMA.getField(ANYTHING_SCHEMA_BYTES_FIELD).pos());
                if (bytesVal != null) {
                    return toConnectData(SchemaBuilder.bytes().build(), bytesVal, toConnectContext);
                }

                Object
                        dblVal =
                        recordValue.get(ANYTHING_SCHEMA.getField(ANYTHING_SCHEMA_DOUBLE_FIELD).pos());
                if (dblVal != null) {
                    return toConnectData(SchemaBuilder.float64().build(), dblVal, toConnectContext);
                }

                Object
                        fltVal =
                        recordValue.get(ANYTHING_SCHEMA.getField(ANYTHING_SCHEMA_FLOAT_FIELD).pos());
                if (fltVal != null) {
                    return toConnectData(SchemaBuilder.float32().build(), fltVal, toConnectContext);
                }

                Object intVal = recordValue.get(ANYTHING_SCHEMA.getField(ANYTHING_SCHEMA_INT_FIELD).pos());
                if (intVal != null) {
                    return toConnectData(SchemaBuilder.int32().build(), intVal, toConnectContext);
                }

                Object
                        longVal =
                        recordValue.get(ANYTHING_SCHEMA.getField(ANYTHING_SCHEMA_LONG_FIELD).pos());
                if (longVal != null) {
                    return toConnectData(SchemaBuilder.int64().build(), longVal, toConnectContext);
                }

                Object
                        stringVal =
                        recordValue.get(ANYTHING_SCHEMA.getField(ANYTHING_SCHEMA_STRING_FIELD).pos());
                if (stringVal != null) {
                    return toConnectData(SchemaBuilder.string().build(), stringVal, toConnectContext);
                }

                Object
                        arrayVal =
                        recordValue.get(ANYTHING_SCHEMA.getField(ANYTHING_SCHEMA_ARRAY_FIELD).pos());
                if (arrayVal != null) {
                    // We cannot reuse the logic like we do in other cases because it is not possible to
                    // construct an array schema with a null item schema, but the items have no schema.
                    if (!(arrayVal instanceof Collection)) {
                        throw new ConnectException(
                                "Expected a Collection for schemaless array field but found a "
                                        + arrayVal.getClass().getName()
                        );
                    }
                    Collection<Object> original = (Collection<Object>) arrayVal;
                    List<Object> result = new ArrayList<>(original.size());
                    for (Object elem : original) {
                        result.add(toConnectData(null, elem, toConnectContext));
                    }
                    return result;
                }

                Object mapVal = recordValue.get(ANYTHING_SCHEMA.getField(ANYTHING_SCHEMA_MAP_FIELD).pos());
                if (mapVal != null) {
                    // We cannot reuse the logic like we do in other cases because it is not possible to
                    // construct a map schema with a null item schema, but the items have no schema.
                    if (!(mapVal instanceof Collection)) {
                        throw new ConnectException(
                                "Expected a List for schemaless map field but found a "
                                        + mapVal.getClass().getName()
                        );
                    }
                    Collection<IndexedRecord> original = (Collection<IndexedRecord>) mapVal;
                    Map<Object, Object> result = new HashMap<>(original.size());
                    for (IndexedRecord entry : original) {
                        int avroKeyFieldIndex = entry.getSchema().getField(KEY_FIELD).pos();
                        int avroValueFieldIndex = entry.getSchema().getField(VALUE_FIELD).pos();
                        Object convertedKey = toConnectData(
                                null, entry.get(avroKeyFieldIndex), toConnectContext);
                        Object convertedValue = toConnectData(
                                null, entry.get(avroValueFieldIndex), toConnectContext);
                        result.put(convertedKey, convertedValue);
                    }
                    return result;
                }

                // If nothing was set, it's null
                return null;
            }

            Object converted = null;
            switch (schema.getFieldType()) {
                // Pass through types
                case INT32: {
                    Integer intValue = (Integer) value; // Validate type
                    converted = value;
                    break;
                }
                case INT64: {
                    Long longValue = (Long) value; // Validate type
                    converted = value;
                    break;
                }
                case FLOAT32: {
                    Float floatValue = (Float) value; // Validate type
                    converted = value;
                    break;
                }
                case FLOAT64: {
                    Double doubleValue = (Double) value; // Validate type
                    converted = value;
                    break;
                }
                case BOOLEAN: {
                    Boolean boolValue = (Boolean) value; // Validate type
                    converted = value;
                    break;
                }

                case INT8:
                    // Encoded as an Integer
                    converted = ((Integer) value).byteValue();
                    break;
                case INT16:
                    // Encoded as an Integer
                    converted = ((Integer) value).shortValue();
                    break;

                case STRING:
                    if (value instanceof String) {
                        converted = value;
                    } else if (value instanceof CharSequence
                            || value instanceof GenericEnumSymbol
                            || value instanceof Enum) {
                        converted = value.toString();
                    } else {
                        throw new ConnectException("Invalid class for string type, expecting String or "
                                + "CharSequence but found " + value.getClass());
                    }
                    break;

                case BYTES:
                    if (value instanceof byte[]) {
                        converted = ByteBuffer.wrap((byte[]) value);
                    } else if (value instanceof ByteBuffer) {
                        converted = value;
                    } else if (value instanceof GenericFixed) {
                        converted = ByteBuffer.wrap(((GenericFixed) value).bytes());
                    } else {
                        throw new ConnectException("Invalid class for bytes type, expecting byte[] or ByteBuffer "
                                + "but found " + value.getClass());
                    }
                    break;

                case ARRAY: {
                    Schema valueSchema = schema.getValueSchema();
                    Collection<Object> original = (Collection<Object>) value;
                    List<Object> result = new ArrayList<>(original.size());
                    for (Object elem : original) {
                        result.add(toConnectData(valueSchema, elem, toConnectContext));
                    }
                    converted = result;
                    break;
                }

                case MAP: {
                    Schema keySchema = schema.getKeySchema();
                    Schema valueSchema = schema.getValueSchema();
                    if (keySchema != null && keySchema.getFieldType() == FieldType.STRING && !keySchema.isOptional()) {
                        // Non-optional string keys
                        Map<CharSequence, Object> original = (Map<CharSequence, Object>) value;
                        Map<CharSequence, Object> result = new HashMap<>(original.size());
                        for (Map.Entry<CharSequence, Object> entry : original.entrySet()) {
                            result.put(entry.getKey().toString(),
                                    toConnectData(valueSchema, entry.getValue(), toConnectContext));
                        }
                        converted = result;
                    } else {
                        // Arbitrary keys
                        Collection<IndexedRecord> original = (Collection<IndexedRecord>) value;
                        Map<Object, Object> result = new HashMap<>(original.size());
                        for (IndexedRecord entry : original) {
                            int avroKeyFieldIndex = entry.getSchema().getField(KEY_FIELD).pos();
                            int avroValueFieldIndex = entry.getSchema().getField(VALUE_FIELD).pos();
                            Object convertedKey = toConnectData(
                                    keySchema, entry.get(avroKeyFieldIndex), toConnectContext);
                            Object convertedValue = toConnectData(
                                    valueSchema, entry.get(avroValueFieldIndex), toConnectContext);
                            result.put(convertedKey, convertedValue);
                        }
                        converted = result;
                    }
                    break;
                }

                case STRUCT: {
                    // Special case support for union types
                    if (schema.getName() != null && schema.getName().equals(AVRO_TYPE_UNION)) {
                        Schema valueRecordSchema = null;
                        if (value instanceof IndexedRecord) {
                            IndexedRecord valueRecord = (IndexedRecord) value;
                            valueRecordSchema = toConnectSchemaWithCycles(
                                    valueRecord.getSchema(), true, null, null, toConnectContext);
                        }
                        for (Field field : schema.getFields()) {
                            Schema fieldSchema = field.getSchema();
                            if (isInstanceOfAvroSchemaTypeForSimpleSchema(
                                    fieldSchema, value, enhancedSchemaSupport)
                                    || (valueRecordSchema != null && schemaEquals(valueRecordSchema, fieldSchema))) {
                                converted = new Struct(schema).put(
                                        unionMemberFieldName(fieldSchema, enhancedSchemaSupport),
                                        toConnectData(fieldSchema, value, toConnectContext));
                                break;
                            }
                        }
                        if (converted == null) {
                            throw new ConnectException(
                                    "Did not find matching union field for data: " + value);
                        }
                    } else if (value instanceof Map) {
                        // Default values from Avro are returned as Map
                        Map<CharSequence, Object> original = (Map<CharSequence, Object>) value;
                        Struct result = new Struct(schema);
                        for (Field field : schema.getFields()) {
                            Object convertedFieldValue =
                                    toConnectData(field.getSchema(), original.get(field.getName()), toConnectContext);
                            result.put(field, convertedFieldValue);
                        }
                        return result;
                    } else {
                        IndexedRecord original = (IndexedRecord) value;
                        Struct result = new Struct(schema);
                        for (Field field : schema.getFields()) {
                            int avroFieldIndex = original.getSchema().getField(field.getName()).pos();
                            Object convertedFieldValue =
                                    toConnectData(field.getSchema(), original.get(avroFieldIndex), toConnectContext);
                            result.put(field, convertedFieldValue);
                        }
                        converted = result;
                    }
                    break;
                }

                default:
                    throw new ConnectException("Unknown Connect schema type: " + schema.getFieldType());
            }

            if (schema.getName() != null && doLogicalConversion) {
                LogicalTypeConverter logicalConverter = TO_CONNECT_LOGICAL_CONVERTERS.get(schema.getName());
                if (logicalConverter != null) {
                    converted = logicalConverter.convert(schema, converted);
                }
            }
            return converted;
        } catch (ClassCastException e) {
            String schemaType = schema != null ? schema.getName() : "null";
            throw new ConnectException("Invalid type for " + schemaType + ": " + value.getClass());
        }
    }

    protected boolean getForceOptionalDefault() {
        return false;
    }

    public Schema toConnectSchema(org.apache.avro.Schema schema) {
        return toConnectSchema(schema, null, new ToConnectContext());
    }

    private Schema toConnectSchema(org.apache.avro.Schema schema,
                                   Integer version,
                                   ToConnectContext toConnectContext) {

        AvroSchema schemaAndVersion = new AvroSchema(schema, version);
        Schema cachedSchema = toConnectSchemaCache.get(schemaAndVersion);
        if (cachedSchema != null) {
            if (schema.getType() == org.apache.avro.Schema.Type.RECORD) {
                toConnectContext.cycleReferences.put(schema, new CyclicSchemaWrapper(cachedSchema));
            }
            return cachedSchema;
        }

        Schema resultSchema = toConnectSchema(schema, getForceOptionalDefault(), null,
                null, version, toConnectContext);
        toConnectSchemaCache.put(schemaAndVersion, resultSchema);
        return resultSchema;
    }

    /**
     * Convert to connect schema
     *
     * @param schema
     * @param forceOptional
     * @param fieldDefaultVal
     * @param docDefaultVal
     * @param toConnectContext
     * @return
     */
    private Schema toConnectSchema(org.apache.avro.Schema schema,
                                   boolean forceOptional,
                                   Object fieldDefaultVal,
                                   String docDefaultVal,
                                   ToConnectContext toConnectContext) {
        return toConnectSchema(
                schema, forceOptional, fieldDefaultVal, docDefaultVal, null, toConnectContext);

    }

    private Schema toConnectSchema(org.apache.avro.Schema schema,
                                   boolean forceOptional,
                                   Object fieldDefaultVal,
                                   String docDefaultVal,
                                   Integer version,
                                   ToConnectContext toConnectContext) {

        String type = schema.getProp(CONNECT_TYPE);
        String logicalType = schema.getProp(AVRO_LOGICAL_TYPE_PROP);
        final SchemaBuilder builder;
        switch (schema.getType()) {
            case BOOLEAN:
                builder = SchemaBuilder.bool();
                break;
            case BYTES:
            case FIXED:
                if (AVRO_LOGICAL_DECIMAL.equalsIgnoreCase(logicalType)) {
                    Object scaleNode = schema.getObjectProp(AVRO_LOGICAL_DECIMAL_SCALE_PROP);
                    if (null == scaleNode || !(scaleNode instanceof Number)) {
                        throw new ConnectException("scale must be specified and must be a number.");
                    }
                    Number scale = (Number) scaleNode;
                    builder = Decimal.builder(scale.intValue());

                    Object precisionNode = schema.getObjectProp(AVRO_LOGICAL_DECIMAL_PRECISION_PROP);
                    if (null != precisionNode) {
                        if (!(precisionNode instanceof Number)) {
                            throw new ConnectException(AVRO_LOGICAL_DECIMAL_PRECISION_PROP
                                    + " property must be a JSON Integer."
                                    + " https://avro.apache.org/docs/1.9.1/spec.html#Decimal");
                        }
                        // Capture the precision as a parameter only if it is not the default
                        Integer precision = ((Number) precisionNode).intValue();
                        if (precision != CONNECT_AVRO_DECIMAL_PRECISION_DEFAULT) {
                            builder.parameter(CONNECT_AVRO_DECIMAL_PRECISION_PROP, precision.toString());
                        }
                    }
                } else {
                    builder = SchemaBuilder.bytes();
                }
                if (schema.getType() == org.apache.avro.Schema.Type.FIXED) {
                    builder.parameter(CONNECT_AVRO_FIXED_SIZE, String.valueOf(schema.getFixedSize()));
                }
                break;
            case DOUBLE:
                builder = SchemaBuilder.float64();
                break;
            case FLOAT:
                builder = SchemaBuilder.float32();
                break;
            case INT:
                // INT is used for Connect's INT8, INT16, and INT32
                if (type == null && logicalType == null) {
                    builder = SchemaBuilder.int32();
                } else if (logicalType != null) {
                    if (AVRO_LOGICAL_DATE.equalsIgnoreCase(logicalType)) {
                        builder = Date.builder();
                    } else if (AVRO_LOGICAL_TIME_MILLIS.equalsIgnoreCase(logicalType)) {
                        builder = Time.builder();
                    } else {
                        builder = SchemaBuilder.int32();
                    }
                } else {
                    FieldType connectType = NON_AVRO_TYPES_BY_TYPE_CODE.get(type);
                    if (connectType == null) {
                        throw new ConnectException("Connect type annotation for Avro int field is null");
                    }
                    builder = new SchemaBuilder(connectType);
                }
                break;
            case LONG:
                if (AVRO_LOGICAL_TIMESTAMP_MILLIS.equalsIgnoreCase(logicalType)) {
                    builder = Timestamp.builder();
                } else {
                    builder = SchemaBuilder.int64();
                }
                break;
            case STRING:
                builder = SchemaBuilder.string();
                break;

            case ARRAY:
                org.apache.avro.Schema elemSchema = schema.getElementType();
                // Special case for custom encoding of non-string maps as list of key-value records
                if (isMapEntry(elemSchema)) {
                    if (elemSchema.getFields().size() != 2
                            || elemSchema.getField(KEY_FIELD) == null
                            || elemSchema.getField(VALUE_FIELD) == null) {
                        throw new ConnectException("Found map encoded as array of key-value pairs, but array "
                                + "elements do not match the expected format.");
                    }
                    builder = SchemaBuilder.map(
                            toConnectSchema(elemSchema.getField(KEY_FIELD).schema()),
                            toConnectSchema(elemSchema.getField(VALUE_FIELD).schema())
                    );
                } else {
                    Schema arraySchema = toConnectSchemaWithCycles(
                            schema.getElementType(), getForceOptionalDefault(),
                            null, null, toConnectContext);
                    builder = SchemaBuilder.array(arraySchema);
                }
                break;

            case MAP:
                builder = SchemaBuilder.map(
                        SchemaBuilder.string().build(),
                        toConnectSchemaWithCycles(
                                schema.getValueType(),
                                getForceOptionalDefault(),
                                null,
                                null,
                                toConnectContext
                        )
                );
                break;

            case RECORD: {
                builder = SchemaBuilder.struct();
                toConnectContext.cycleReferences.put(schema, new CyclicSchemaWrapper(builder.build()));
                if (connectMetaData && schema.getDoc() != null) {
                    builder.parameter(AVRO_RECORD_DOC, schema.getDoc());
                }
                for (org.apache.avro.Schema.Field field : schema.getFields()) {
                    if (connectMetaData && field.doc() != null) {
                        builder.parameter(AVRO_FIELD_DOC_PREFIX + field.name(), field.doc());
                    }
                    Schema fieldSchema = toConnectSchema(field.schema(), getForceOptionalDefault(),
                            field.defaultVal(), field.doc(), toConnectContext);
                    builder.field(field.name(), fieldSchema);
                }
                break;
            }

            case ENUM:
                // enums are unwrapped to strings and the original enum is not preserved
                builder = SchemaBuilder.string();
                if (connectMetaData) {
                    if (schema.getDoc() != null) {
                        builder.parameter(AVRO_ENUM_DOC_PREFIX + schema.getName(), schema.getDoc());
                    }
                    if (schema.getEnumDefault() != null) {
                        builder.parameter(AVRO_ENUM_DEFAULT_PREFIX + schema.getName(),
                                schema.getEnumDefault());
                    }
                }
                builder.parameter(AVRO_TYPE_ENUM, schema.getFullName());
                for (String enumSymbol : schema.getEnumSymbols()) {
                    builder.parameter(AVRO_TYPE_ENUM + "." + enumSymbol, enumSymbol);
                }
                break;

            case UNION: {
                if (schema.getTypes().size() == 2) {
                    if (schema.getTypes().contains(NULL_SCHEMA)) {
                        for (org.apache.avro.Schema memberSchema : schema.getTypes()) {
                            if (!memberSchema.equals(NULL_SCHEMA)) {
                                return toConnectSchemaWithCycles(
                                        memberSchema, true, null, docDefaultVal, toConnectContext);
                            }
                        }
                    }
                }
                builder = SchemaBuilder.struct().name(AVRO_TYPE_UNION);
                Set<String> fieldNames = new HashSet<>();
                for (org.apache.avro.Schema memberSchema : schema.getTypes()) {
                    if (memberSchema.getType() == org.apache.avro.Schema.Type.NULL) {
                        builder.optional();
                    } else {
                        String fieldName = unionMemberFieldName(memberSchema, enhancedSchemaSupport);
                        if (fieldNames.contains(fieldName)) {
                            throw new ConnectException("Multiple union schemas map to the Connect union field name");
                        }
                        fieldNames.add(fieldName);
                        builder.field(
                                fieldName,
                                toConnectSchemaWithCycles(memberSchema, true, null, null, toConnectContext)
                        );
                    }
                }
                break;
            }

            case NULL:
                throw new ConnectException("Standalone null schemas are not supported by this converter");

            default:
                throw new ConnectException("Couldn't translate unsupported schema type "
                        + schema.getType().getName() + ".");
        }

        String docVal = schema.getProp(CONNECT_DOC);
        if (connectMetaData && docVal != null) {
            builder.doc(docVal);
        }

        // A valid version must be a positive integer (assumed throughout SR)
        int versionInt = -1;
        Object versionNode = schema.getObjectProp(CONNECT_VERSION);
        if (versionNode != null) {
            if (!(versionNode instanceof Number)) {
                throw new ConnectException("Invalid Connect version found: " + versionNode);
            }
            versionInt = ((Number) versionNode).intValue();
        } else if (version != null) {
            versionInt = version.intValue();
        }
        if (versionInt >= 0) {
            if (builder.build().getVersion() != null) {
                if (versionInt != builder.build().getVersion()) {
                    throw new ConnectException("Mismatched versions: version already added to SchemaBuilder "
                            + "("
                            + builder.build().getVersion()
                            + ") differs from version in source schema ("
                            + versionInt
                            + ")");
                }
            } else {
                builder.version(versionInt);
            }
        }

        Object parameters = schema.getObjectProp(CONNECT_PARAMETERS);
        if (connectMetaData && parameters != null) {
            if (!(parameters instanceof Map)) {
                throw new ConnectException("Expected JSON object for schema parameters but found: "
                        + parameters);
            }
            Iterator<Map.Entry<String, Object>> paramIt =
                    ((Map<String, Object>) parameters).entrySet().iterator();
            while (paramIt.hasNext()) {
                Map.Entry<String, Object> field = paramIt.next();
                Object jsonValue = field.getValue();
                if (!(jsonValue instanceof String)) {
                    throw new ConnectException("Expected schema parameter values to be strings but found: "
                            + jsonValue);
                }
                builder.parameter(field.getKey(), (String) jsonValue);
            }
        }

        for (Map.Entry<String, Object> entry : schema.getObjectProps().entrySet()) {
            if (entry.getKey().startsWith(AVRO_PROP)) {
                builder.parameter(entry.getKey(), entry.getValue().toString());
            }
        }

        Object connectDefault = schema.getObjectProp(CONNECT_DEFAULT_VALUE);
        if (fieldDefaultVal == null) {
            fieldDefaultVal = JacksonUtils.toJsonNode(connectDefault);
        } else if (connectDefault == null) {
            builder.parameter(AVRO_FIELD_DEFAULT_FLAG, "true");
        }
        if (fieldDefaultVal != null) {
            builder.defaultValue(
                    defaultValueFromAvro(builder.build(), schema, fieldDefaultVal, toConnectContext));
        }

        Object connectNameJson = schema.getObjectProp(CONNECT_NAME);
        String name = null;
        if (connectNameJson != null) {
            if (!(connectNameJson instanceof String)) {
                throw new ConnectException("Invalid schema name: " + connectNameJson);
            }
            name = (String) connectNameJson;

        } else if (schema.getType() == org.apache.avro.Schema.Type.RECORD
                || schema.getType() == org.apache.avro.Schema.Type.ENUM
                || schema.getType() == org.apache.avro.Schema.Type.FIXED) {
            name = schema.getFullName();
        }
        if (name != null && !name.equals(DEFAULT_SCHEMA_FULL_NAME)) {
            if (builder.build().getName() != null) {
                if (!name.equals(builder.build().getName())) {
                    throw new ConnectException("Mismatched names: name already added to SchemaBuilder ("
                            + builder.build().getName()
                            + ") differs from name in source schema ("
                            + name + ")");
                }
            } else {
                builder.name(name);
            }
        }

        if (forceOptional) {
            builder.optional();
        }

        if (!toConnectContext.detectedCycles.contains(schema)) {
            toConnectContext.cycleReferences.remove(schema);
        }

        return builder.build();
    }

    private Schema toConnectSchemaWithCycles(org.apache.avro.Schema schema,
                                             boolean forceOptional,
                                             Object fieldDefaultVal,
                                             String docDefaultVal,
                                             ToConnectContext toConnectContext) {
        Schema resolvedSchema;
        if (toConnectContext.cycleReferences.containsKey(schema)) {
            toConnectContext.detectedCycles.add(schema);
            resolvedSchema = cyclicSchemaWrapper(toConnectContext.cycleReferences, schema, forceOptional);
        } else {
            resolvedSchema = toConnectSchema(
                    schema, forceOptional, fieldDefaultVal, docDefaultVal, toConnectContext);
        }
        return resolvedSchema;
    }

    private CyclicSchemaWrapper cyclicSchemaWrapper(
            Map<org.apache.avro.Schema, CyclicSchemaWrapper> toConnectCycles,
            org.apache.avro.Schema memberSchema,
            boolean optional) {
        return new CyclicSchemaWrapper(toConnectCycles.get(memberSchema).schema(), optional);
    }

    private Object defaultValueFromAvro(Schema schema,
                                        org.apache.avro.Schema avroSchema,
                                        Object value,
                                        ToConnectContext toConnectContext) {
        Object result = defaultValueFromAvroWithoutLogical(schema, avroSchema, value, toConnectContext);
        // If the schema is a logical type, convert the primitive Avro default into the logical form
        return toConnectLogical(schema, result);
    }

    private Object defaultValueFromAvroWithoutLogical(Schema schema,
                                                      org.apache.avro.Schema avroSchema,
                                                      Object value,
                                                      ToConnectContext toConnectContext) {
        if (value == null || value == JsonProperties.NULL_VALUE) {
            return null;
        }

        // The type will be JsonNode if this default was pulled from a Connect default field, or an
        // Object if it's the actual Avro-specified default. If it's a regular Java object, we can
        // use our existing conversion tools.
        if (!(value instanceof JsonNode)) {
            return toConnectData(schema, value, toConnectContext, false);
        }

        JsonNode jsonValue = (JsonNode) value;
        switch (avroSchema.getType()) {
            case INT:
                if (schema.getFieldType() == FieldType.INT8) {
                    return (byte) jsonValue.intValue();
                } else if (schema.getFieldType() == FieldType.INT16) {
                    return jsonValue.shortValue();
                } else if (schema.getFieldType() == FieldType.INT32) {
                    return jsonValue.intValue();
                } else {
                    break;
                }

            case LONG:
                return jsonValue.longValue();

            case FLOAT:
                return (float) jsonValue.doubleValue();
            case DOUBLE:
                return jsonValue.doubleValue();

            case BOOLEAN:
                return jsonValue.asBoolean();

            case NULL:
                return null;

            case STRING:
            case ENUM:
                return jsonValue.asText();

            case BYTES:
            case FIXED:
                try {
                    byte[] bytes;
                    if (jsonValue.isTextual()) {
                        // Avro's JSON form may be a quoted string, so decode the binary value
                        String encoded = jsonValue.textValue();
                        bytes = encoded.getBytes(StandardCharsets.ISO_8859_1);
                    } else {
                        bytes = jsonValue.binaryValue();
                    }
                    return bytes == null ? null : ByteBuffer.wrap(bytes);
                } catch (IOException e) {
                    throw new ConnectException("Invalid binary data in default value", e);
                }

            case ARRAY: {
                if (!jsonValue.isArray()) {
                    throw new ConnectException("Invalid JSON for array default value: " + jsonValue);
                }
                List<Object> result = new ArrayList<>(jsonValue.size());
                for (JsonNode elem : jsonValue) {
                    result.add(
                            defaultValueFromAvro(schema, avroSchema.getElementType(), elem, toConnectContext));
                }
                return result;
            }

            case MAP: {
                if (!jsonValue.isObject()) {
                    throw new ConnectException("Invalid JSON for map default value: " + jsonValue);
                }
                Map<String, Object> result = new HashMap<>(jsonValue.size());
                Iterator<Map.Entry<String, JsonNode>> fieldIt = jsonValue.fields();
                while (fieldIt.hasNext()) {
                    Map.Entry<String, JsonNode> field = fieldIt.next();
                    Object converted = defaultValueFromAvro(
                            schema.getValueSchema(), avroSchema.getValueType(), field.getValue(), toConnectContext);
                    result.put(field.getKey(), converted);
                }
                return result;
            }

            case RECORD: {
                if (!jsonValue.isObject()) {
                    throw new ConnectException("Invalid JSON for record default value: " + jsonValue);
                }

                Struct result = new Struct(schema);
                for (org.apache.avro.Schema.Field avroField : avroSchema.getFields()) {
                    Field field = schema.getField(avroField.name());
                    JsonNode fieldJson = ((JsonNode) value).get(field.getName());
                    Object converted = defaultValueFromAvro(
                            field.getSchema(), avroField.schema(), fieldJson, toConnectContext);
                    result.put(avroField.name(), converted);
                }
                return result;
            }

            case UNION: {
                // Defaults must match first type
                org.apache.avro.Schema memberAvroSchema = avroSchema.getTypes().get(0);
                if (memberAvroSchema.getType() == org.apache.avro.Schema.Type.NULL) {
                    return null;
                } else {
                    return defaultValueFromAvro(
                            schema.getField(unionMemberFieldName(memberAvroSchema, enhancedSchemaSupport)).getSchema(),
                            memberAvroSchema,
                            value,
                            toConnectContext);
                }
            }
            default: {
                return null;
            }
        }
        return null;
    }

    private interface LogicalTypeConverter {

        Object convert(Schema schema, Object value);
    }

    private static class SchemaPair {
        public Schema one;
        public Schema two;

        public SchemaPair(Schema one, Schema two) {
            this.one = one;
            this.two = two;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (o == null || getClass() != o.getClass()) {
                return false;
            }
            SchemaPair that = (SchemaPair) o;
            return Objects.equals(one, that.one)
                    && Objects.equals(two, that.two);
        }

        @Override
        public int hashCode() {
            return Objects.hash(one, two);
        }
    }

    private static class CyclicSchemaWrapper extends Schema {

        private final Schema schema;
        private final boolean optional;

        public CyclicSchemaWrapper(Schema schema) {
            this(schema, schema.isOptional());
        }

        public CyclicSchemaWrapper(Schema schema, boolean optional) {
            super(schema.getName(), schema.getFieldType(), schema.getFields());
            this.schema = schema;
            this.optional = optional;
        }


        public FieldType type() {
            return schema.getFieldType();
        }


        public boolean isOptional() {
            return optional;
        }


        public Object defaultValue() {
            return schema.getDefaultValue();
        }


        public String name() {
            return schema.getName();
        }


        public Integer version() {
            return schema.getVersion();
        }


        public String doc() {
            return schema.getDoc();
        }


        public Map<String, String> parameters() {
            return schema.getParameters();
        }


        public Schema keySchema() {
            return schema.getKeySchema();
        }


        public Schema valueSchema() {
            return schema.getValueSchema();
        }


        public List<Field> fields() {
            return schema.getFields();
        }


        public Field field(String s) {
            return schema.getField(s);
        }


        public Schema schema() {
            return schema;
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }

            if (o == null || getClass() != o.getClass()) {
                return false;
            }

            CyclicSchemaWrapper other = (CyclicSchemaWrapper) o;
            return Objects.equals(optional, other.optional) && Objects.equals(schema, other.schema);
        }

        @Override
        public int hashCode() {
            return Objects.hashCode(optional) + Objects.hashCode(schema);
        }
    }

    /**
     * Class that holds the context for performing {@code toConnectSchema}
     */
    private static class ToConnectContext {
        private final Map<org.apache.avro.Schema, CyclicSchemaWrapper> cycleReferences;
        private final Set<org.apache.avro.Schema> detectedCycles;

        /**
         * cycleReferences - map that holds connect Schema references to resolve cycles
         * detectedCycles - avro schemas that have been detected to have cycles
         */
        private ToConnectContext() {
            this.cycleReferences = new IdentityHashMap<>();
            this.detectedCycles = new HashSet<>();
        }
    }


    private static class FromConnectContext {
        //SchemaMap is used to resolve references that need to mapped as types
        private final Map<Schema, org.apache.avro.Schema> schemaMap;
        //schema name to Schema reference to resolve cycles
        private final Map<String, org.apache.avro.Schema> cycleReferences;

        private FromConnectContext(Map<Schema, org.apache.avro.Schema> schemaMap) {
            this.schemaMap = schemaMap;
            this.cycleReferences = new IdentityHashMap<>();
        }
    }

}
